Compare commits

...
4 Commits
21 changed files with 399 additions and 330 deletions
+43 -83
View File
@@ -1,16 +1,16 @@
import argparse import argparse
import json
import logging import logging
import os import os
from urllib.parse import urlparse from urllib.parse import urlparse
import msgspec
from fastapi_vue import server from fastapi_vue import server
from fastapi_vue.hostutil import parse_endpoints from fastapi_vue.hostutil import parse_endpoints
from paskia.config import PaskiaConfig
from paskia.db.jsonl import load_readonly from paskia.db.jsonl import load_readonly
from paskia.util import startupbox from paskia.util import startupbox
from paskia.util.hostutil import normalize_origin from paskia.util.hostutil import normalize_origin
from paskia.util.runtime import RuntimeConfig
DEFAULT_PORT = 4401 DEFAULT_PORT = 4401
DEVMODE = os.getenv("PASKIA_DEV") == "1" DEVMODE = os.getenv("PASKIA_DEV") == "1"
@@ -51,7 +51,6 @@ def add_common_options(p: argparse.ArgumentParser) -> None:
"--origin", "--origin",
action="append", action="append",
dest="origins", dest="origins",
default=[],
metavar="URL", metavar="URL",
help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.", help="Allowed origin URL(s). May be specified multiple times. If any are specified, only those origins are permitted for WebSocket authentication.",
) )
@@ -91,105 +90,66 @@ def main():
args = parser.parse_args() args = parser.parse_args()
# Handle clearing options # Load stored config (read-only, no writes, no global state)
if getattr(args, "auth_host", None) == "":
args.auth_host = None
if getattr(args, "rp_name", None) == "":
args.rp_name = None
if getattr(args, "listen", None) == "":
args.listen = None
# Read-only load to get stored config (no writes, no global state)
db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb") db_path = os.environ.get("PASKIA_DB", f"{args.rp_id}.paskiadb")
stored_db = load_readonly(db_path, rp_id=args.rp_id) config = load_readonly(db_path, rp_id=args.rp_id).config
stored_config = stored_db.config
# Apply defaults from stored config # Override stored config with CLI args, or clear with empty string
if args.rp_name is None and stored_config.rp_name is not None: if args.rp_name is not None:
args.rp_name = stored_config.rp_name config.rp_name = args.rp_name or None
if args.origins is None and stored_config.origins is not None: if args.auth_host is not None:
args.origins = stored_config.origins config.auth_host = args.auth_host or None
if args.auth_host is None and stored_config.auth_host is not None: if args.origins is not None:
args.auth_host = stored_config.auth_host config.origins = None if args.origins == [""] else args.origins
if args.listen is None and stored_config.listen is not None: if args.listen is not None:
args.listen = stored_config.listen config.listen = None if args.listen == [""] else args.listen
# Parse first endpoint for config display and site_url
ep = next(iter(parse_endpoints(args.listen, DEFAULT_PORT)), {})
host, port, uds = ep.get("host"), ep.get("port"), ep.get("uds")
# Process and normalize auth_host # Process and normalize auth_host
if args.auth_host: if config.auth_host:
if "://" not in args.auth_host: if "://" not in config.auth_host:
args.auth_host = f"https://{args.auth_host}" config.auth_host = f"https://{config.auth_host}"
args.auth_host = args.auth_host.rstrip("/") config.auth_host = config.auth_host.rstrip("/")
validate_auth_host(args.auth_host, args.rp_id) validate_auth_host(config.auth_host, config.rp_id)
args.origins.insert(0, args.auth_host) # Ensure first in origins if config.origins:
config.origins.insert(0, config.auth_host) # Ensure first in origins
# Normalize, strip trailing slashes, and deduplicate while preserving order # Normalize and deduplicate while preserving order
origins = list({normalize_origin(o).rstrip("/"): ... for o in (args.origins)}) if config.origins:
config.origins = list({normalize_origin(o): ... for o in config.origins})
# Compute site_url and site_path for reset links # Parse first endpoint for site_url fallback
# Priority: auth_host > first configured origin > PASKIA_VITE_URL (devserver) > http://localhost:port > https://rp_id ep = next(iter(parse_endpoints(config.listen, DEFAULT_PORT)), {})
port = ep.get("port")
# Compute site_url and site_path
# Priority: auth_host > origins[0] > PASKIA_VITE_URL > http://localhost:port > https://rp_id
site_path = "/auth/" site_path = "/auth/"
if args.auth_host: if config.auth_host:
site_url = args.auth_host site_url, site_path = config.auth_host, "/"
site_path = "/" elif config.origins:
elif origins: site_url = config.origins[0]
# Find localhost origin if rp_id is localhost, else use first origin
localhost_origin = (
next((o for o in origins if "://localhost" in o), None)
if args.rp_id == "localhost"
else None
)
site_url = localhost_origin or origins[0]
elif vite_url := os.environ.get("PASKIA_VITE_URL"): elif vite_url := os.environ.get("PASKIA_VITE_URL"):
site_url = vite_url.rstrip("/") # Devserver site_url = vite_url.rstrip("/") # Devserver
elif args.rp_id == "localhost" and port: elif config.rp_id == "localhost" and port:
site_url = f"http://localhost:{port}" # Backend directly if we can site_url = f"http://localhost:{port}" # Backend directly if we can
else: else:
site_url = f"https://{args.rp_id}" # Assume external reverse proxy site_url = f"https://{config.rp_id}" # Assume external reverse proxy
# Build runtime configuration # Build runtime configuration for the server
config = PaskiaConfig( runtime = RuntimeConfig(
rp_id=args.rp_id, config=config,
rp_name=args.rp_name or None,
origins=origins or None,
auth_host=args.auth_host or None,
site_url=site_url, site_url=site_url,
site_path=site_path, site_path=site_path,
host=host, save=args.save,
port=port,
uds=uds,
) )
startupbox.print_startup_config(runtime)
os.environ["PASKIA_CONFIG"] = msgspec.json.encode(runtime).decode()
# Export configuration via single JSON env variable for worker processes # Run the server (spawns processes in dev mode)
# Include cli_config and save flag so lifespan can handle bootstrap/persistence
cli_config = {
"rp_id": args.rp_id,
"rp_name": args.rp_name,
"origins": args.origins,
"auth_host": args.auth_host,
"listen": args.listen,
}
config_json = {
"rp_id": config.rp_id,
"rp_name": config.rp_name,
"origins": config.origins,
"auth_host": config.auth_host,
"site_url": config.site_url,
"site_path": config.site_path,
"save": args.save,
"cli_config": cli_config,
}
os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
startupbox.print_startup_config(config)
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {} dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
server.run( server.run(
"paskia.fastapi.mainapp:app", "paskia.fastapi.mainapp:app",
listen=args.listen, listen=config.listen,
default_port=DEFAULT_PORT, default_port=DEFAULT_PORT,
log_level="warning", log_level="warning",
access_log=False, access_log=False,
+6 -1
View File
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
EXPIRES = SESSION_LIFETIME EXPIRES = SESSION_LIFETIME
def session_ctx(auth: str, host: str | None = None):
"""Get session context with normalized host."""
return db.data().session_ctx(auth, hostutil.normalize_host(host))
def expires() -> datetime: def expires() -> datetime:
return datetime.now(UTC) + EXPIRES return datetime.now(UTC) + EXPIRES
@@ -42,7 +47,7 @@ def get_reset(token: str) -> "ResetToken":
def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None): def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
"""Delete a specific credential for the current user.""" """Delete a specific credential for the current user."""
ctx = db.data().session_ctx(auth, hostutil.normalize_host(host)) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
raise ValueError("Session expired") raise ValueError("Session expired")
db.delete_credential(credential_uuid, ctx.user.uuid) db.delete_credential(credential_uuid, ctx.user.uuid)
-17
View File
@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
# Shared configuration constants for session management. # Shared configuration constants for session management.
@@ -6,19 +5,3 @@ SESSION_LIFETIME = timedelta(hours=24)
# Lifetime for reset links created by admins # Lifetime for reset links created by admins
RESET_LIFETIME = timedelta(days=14) RESET_LIFETIME = timedelta(days=14)
@dataclass
class PaskiaConfig:
"""Runtime configuration for the Paskia authentication server."""
rp_id: str
rp_name: str | None
origins: list[str] | None
auth_host: str | None
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
# Listen address (one of host:port or uds)
host: str | None = None
port: int | None = None
uds: str | None = None
+6 -2
View File
@@ -21,7 +21,7 @@ _background_task: asyncio.Task | None = None
async def flush() -> None: async def flush() -> None:
"""Write all pending database changes to disk.""" """Write all pending database changes to disk."""
store = _ops._store store = _ops._db._store
if store is None: if store is None:
_logger.warning("flush() called but _store is None") _logger.warning("flush() called but _store is None")
return return
@@ -48,6 +48,10 @@ async def _background_loop():
cleanup_expired() cleanup_expired()
await flush() # Flush cleanup changes await flush() # Flush cleanup changes
last_cleanup = now last_cleanup = now
# Conditionally write a snapshot to speed up future startups
if _ops._db._store is not None:
_ops._db._store.maybe_snapshot()
except asyncio.CancelledError: except asyncio.CancelledError:
# Final flush before exit # Final flush before exit
await flush() await flush()
@@ -99,7 +103,7 @@ async def stop_background():
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
_background_task = None _background_task = None
_ops._store.close() _ops._db._store.close()
# Aliases for backwards compatibility # Aliases for backwards compatibility
+104 -88
View File
@@ -25,12 +25,56 @@ from paskia.db.migrations import (
apply_all_migrations, apply_all_migrations,
apply_migrations_readonly, apply_migrations_readonly,
) )
from paskia.db.snapshot import SnapshotState
from paskia.db.structs import DB, Config, SessionContext from paskia.db.structs import DB, Config, SessionContext
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Default database path
DB_PATH_DEFAULT = "paskia.jsonl" class ReplayResult(msgspec.Struct, frozen=False):
"""Return value of _replay_from_data"""
state: dict
v: int = 0
ts: datetime | None = None
snapts: datetime | None = None
changes: int = 0
class DatabaseError(Exception):
"""Exception raised for database loading errors."""
pass
def _replay_from_data(data: bytes, db_path: str) -> ReplayResult:
"""Replay database state from file data, using the last snapshot if available."""
resolved_path = str(Path(db_path).resolve())
result = ReplayResult(state={})
# Find and apply the last snapshot
snap, start_offset = SnapshotState.load(data)
if snap:
result.state = snap.state
result.v = snap.v
result.snapts = snap.ts
# Replay change records after the snapshot
lines = data[start_offset:].split(b"\n")
for line_num, raw in enumerate(lines, start=1): # 1-based line numbering
line = raw.strip()
if not line:
continue
try:
change = msgspec.json.decode(line, type=ChangeRecord)
except msgspec.DecodeError as e:
raise DatabaseError(f"{resolved_path}:{line_num}: {e}")
result.state = jsondiff.patch(result.state, change.diff, marshal=True)
result.v = change.v
result.ts = change.ts
result.changes += 1
return result
def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB: def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
@@ -43,25 +87,20 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
if not path.exists(): if not path.exists():
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
data_dict: dict = {}
version = 0
try: try:
with open(path, "rb") as f: with open(path, "rb") as f:
content = f.read() content = f.read()
for line_num, line in enumerate(content.split(b"\n"), 1): r = _replay_from_data(content, str(path.resolve()))
line = line.strip() data_dict = r.state
if not line: version = r.v
continue
try:
change = msgspec.json.decode(line)
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True)
version = change.get("v", 0)
except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}")
except OSError as e: except OSError as e:
raise SystemExit(f"Failed to load database: {e}") _logger.exception("Failed to load database")
except (ValueError, msgspec.DecodeError) as e: raise SystemExit(f"{e}")
raise SystemExit(f"Failed to load database: {e}") except (ValueError, msgspec.DecodeError, DatabaseError) as e:
raise SystemExit(f"{e}")
except Exception as e:
_logger.exception("Unexpected error loading database")
raise SystemExit(f"{e}")
if not data_dict: if not data_dict:
return DB(config=Config(rp_id=rp_id)) return DB(config=Config(rp_id=rp_id))
@@ -74,45 +113,16 @@ def load_readonly(db_path: str, *, rp_id: str = "localhost") -> DB:
return db return db
class _ChangeRecord(msgspec.Struct, omit_defaults=True): class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
"""A single change record in the JSONL file.""" ts: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
ts: datetime
a: str # action - describes the operation (e.g., "migrate", "login", "create_user") a: str # action - describes the operation (e.g., "migrate", "login", "create_user")
v: int # schema version after this change v: int = 0 # schema version after this change
u: str | None = None # user UUID who performed the action (None for system) u: str | None = None # user UUID who performed the action (None for system)
diff: dict = {} diff: dict
# msgspec encoder for change records
_change_encoder = msgspec.json.Encoder()
def compute_diff(previous: dict, current: dict) -> dict | None: def compute_diff(previous: dict, current: dict) -> dict | None:
"""Compute JSON diff between two states. return jsondiff.diff(previous, current, marshal=True) or None
Args:
previous: Previous state (JSON-compatible dict)
current: Current state (JSON-compatible dict)
Returns:
The diff, or None if no changes
"""
diff = jsondiff.diff(previous, current, marshal=True)
return diff if diff else None
def create_change_record(
action: str, version: int, diff: dict, user: str | None = None
) -> _ChangeRecord:
"""Create a change record for persistence."""
return _ChangeRecord(
ts=datetime.now(UTC),
a=action,
v=version,
u=user,
diff=diff,
)
# Actions that are allowed to create a new database file # Actions that are allowed to create a new database file
@@ -122,18 +132,19 @@ _BOOTSTRAP_ACTIONS = frozenset({"bootstrap"})
class JsonlStore: class JsonlStore:
"""JSONL persistence layer for a DB instance.""" """JSONL persistence layer for a DB instance."""
def __init__(self, db: DB, db_path: str = DB_PATH_DEFAULT): def __init__(self, db: DB, db_path: str):
self.db: DB = db self.db: DB = db
self.db_path = Path(db_path) self.db_path = Path(db_path)
self._file = LockedFile() self._file = LockedFile()
self._flush_failed = False self._flush_failed = False
self._previous_builtins: dict[str, Any] = {} self._statedict: dict[str, Any] = {}
self._pending_changes: deque[_ChangeRecord] = deque() self._pending_changes: deque[ChangeRecord] = deque()
self._current_action: str = "system" self._current_action: str = "system"
self._current_user: str | None = None self._current_user: str | None = None
self._in_transaction: bool = False self._in_transaction: bool = False
self._transaction_snapshot: dict[str, Any] | None = None self._transaction_snapshot: dict[str, Any] | None = None
self._current_version: int = DBVER # Schema version for new databases self._v: int = DBVER # Schema version for new databases
self._snapshot = SnapshotState()
async def load( async def load(
self, db_path: str | None = None, *, rp_id: str = "localhost" self, db_path: str | None = None, *, rp_id: str = "localhost"
@@ -148,56 +159,49 @@ class JsonlStore:
# Open with exclusive write lock and read contents — single threadpool call # Open with exclusive write lock and read contents — single threadpool call
content = await asyncio.to_thread(self._file.open_and_read, self.db_path) content = await asyncio.to_thread(self._file.open_and_read, self.db_path)
# Replay change log to reconstruct state # Replay change log to reconstruct state (snapshot-accelerated)
data_dict: dict = {}
try: try:
for line_num, line in enumerate(content.split(b"\n"), 1): r = _replay_from_data(content, str(self.db_path.resolve()))
line = line.strip() statedict = r.state
if not line: self._v = r.v
continue self._snapshot.ts = r.snapts
try: self._snapshot.changes = r.changes
change = msgspec.json.decode(line) except (OSError, ValueError, msgspec.DecodeError, DatabaseError) as e:
data_dict = jsondiff.patch(data_dict, change["diff"], marshal=True) raise SystemExit(f"{e}")
self._current_version = change.get("v", 0)
except Exception as e: except Exception as e:
raise ValueError(f"Error parsing line {line_num}: {e}") _logger.exception("Unexpected error loading database")
except OSError as e: raise SystemExit(f"{e}")
raise SystemExit(f"Failed to load database: {e}")
except (ValueError, msgspec.DecodeError) as e:
raise SystemExit(f"Failed to load database: {e}")
if not data_dict: if not statedict:
return return
# Set previous state for diffing (will be updated by _queue_change) # Set previous state for diffing (will be updated by _queue_change)
self._previous_builtins = copy.deepcopy(data_dict) self._statedict = copy.deepcopy(statedict)
# Callback to persist each migration # Callback to persist each migration
async def persist_migration( async def persist_migration(
action: str, new_version: int, current: dict action: str, new_version: int, current: dict
) -> None: ) -> None:
self._current_version = new_version self._v = new_version
self._queue_change(action, new_version, current) self._queue_change(action, new_version, current)
# Apply schema migrations one at a time # Apply schema migrations one at a time
await apply_all_migrations( await apply_all_migrations(
data_dict, statedict,
self._current_version, self._v,
persist_migration, persist_migration,
MigrationCtx(rp_id=rp_id), MigrationCtx(rp_id=rp_id),
) )
# Decode to msgspec struct # Decode to msgspec struct
decoder = msgspec.json.Decoder(DB) decoder = msgspec.json.Decoder(DB)
self.db = decoder.decode(msgspec.json.encode(data_dict)) self.db = decoder.decode(msgspec.json.encode(statedict))
self.db._store = self self.db._store = self
# Normalize via msgspec round-trip (handles omit_defaults etc.) # Normalize via msgspec round-trip (handles omit_defaults etc.)
# This ensures _previous_builtins matches what msgspec would produce # This ensures _previous_builtins matches what msgspec would produce
normalized_dict = msgspec.to_builtins(self.db) normalized_dict = msgspec.to_builtins(self.db)
await persist_migration( await persist_migration("migrate:msgspec", self._v, normalized_dict)
"migrate:msgspec", self._current_version, normalized_dict
)
def _queue_change( def _queue_change(
self, action: str, version: int, current: dict, user: str | None = None self, action: str, version: int, current: dict, user: str | None = None
@@ -210,10 +214,17 @@ class JsonlStore:
current: The current state as a plain dict current: The current state as a plain dict
user: Optional user UUID who performed the action user: Optional user UUID who performed the action
""" """
diff = compute_diff(self._previous_builtins, current) diff = compute_diff(self._statedict, current)
if not diff: if not diff:
return return
self._pending_changes.append(create_change_record(action, version, diff, user)) self._pending_changes.append(
ChangeRecord(
a=action,
v=version,
u=user,
diff=diff,
)
)
# Log the change with user display name if available # Log the change with user display name if available
user_display = None user_display = None
@@ -225,8 +236,8 @@ class JsonlStore:
except (ValueError, KeyError): except (ValueError, KeyError):
user_display = user user_display = user
log_change(action, diff, user_display, self._previous_builtins, self.db) log_change(action, diff, user_display, self._statedict, self.db)
self._previous_builtins = copy.deepcopy(current) self._statedict = copy.deepcopy(current)
@contextmanager @contextmanager
def transaction( def transaction(
@@ -248,13 +259,13 @@ class JsonlStore:
# Check for out-of-transaction modifications # Check for out-of-transaction modifications
current_state = msgspec.to_builtins(self.db) current_state = msgspec.to_builtins(self.db)
if current_state != self._previous_builtins: if current_state != self._statedict:
# Allow bootstrap to create a new database from empty state # Allow bootstrap to create a new database from empty state
is_bootstrap = action in _BOOTSTRAP_ACTIONS is_bootstrap = action in _BOOTSTRAP_ACTIONS
if is_bootstrap and not self._previous_builtins: if is_bootstrap and not self._statedict:
pass # Expected: creating database from scratch pass # Expected: creating database from scratch
else: else:
diff = compute_diff(self._previous_builtins, current_state) diff = compute_diff(self._statedict, current_state)
diff_json = msgspec.json.encode(diff).decode() diff_json = msgspec.json.encode(diff).decode()
_logger.critical( _logger.critical(
"Database state modified outside of transaction! " "Database state modified outside of transaction! "
@@ -275,7 +286,7 @@ class JsonlStore:
yield yield
current = msgspec.to_builtins(self.db) current = msgspec.to_builtins(self.db)
self._queue_change( self._queue_change(
self._current_action, self._current_version, current, self._current_user self._current_action, self._v, current, self._current_user
) )
except Exception: except Exception:
# Rollback on error: restore from snapshot # Rollback on error: restore from snapshot
@@ -318,18 +329,23 @@ class JsonlStore:
changes_to_write = list(self._pending_changes) changes_to_write = list(self._pending_changes)
try: try:
lines = [_change_encoder.encode(change) for change in changes_to_write] lines = [msgspec.json.encode(change) for change in changes_to_write]
if not lines: if not lines:
self._pending_changes.clear() self._pending_changes.clear()
return return
await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n") await asyncio.to_thread(self._file.write, b"\n".join(lines) + b"\n")
self._snapshot.record_lines(len(lines))
self._pending_changes.clear() self._pending_changes.clear()
except OSError as e: except OSError as e:
_logger.error("Failed to flush database: %s", e) _logger.error("Failed to flush database: %s", e)
self._flush_failed = True self._flush_failed = True
os.kill(os.getpid(), signal.SIGTERM) os.kill(os.getpid(), signal.SIGTERM)
def maybe_snapshot(self) -> None:
"""Write a snapshot if conditions are met."""
self._snapshot.maybe_write(self._file, self._v, self._statedict)
def close(self) -> None: def close(self) -> None:
"""Release the file lock and close the file.""" """Release the file lock and close the file."""
self._file.close() self._file.close()
+10 -7
View File
@@ -9,20 +9,23 @@ from datetime import UTC, datetime
import paskia.db.operations as _ops import paskia.db.operations as _ops
from paskia import oidc_notify from paskia import oidc_notify
from paskia.authsession import EXPIRES from paskia.authsession import EXPIRES
from paskia.db.jsonl import JsonlStore
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
async def init(rp_id: str = "localhost", *args, **kwargs): async def init(rp_id: str, *args, **kwargs):
"""Load database from JSONL file.""" """Load database from JSONL file."""
if _ops._initialized: if _ops._db._store:
_logger.debug("Database already initialized, skipping reload") _logger.debug("Database already initialized, skipping reload")
return return
default_path = f"{rp_id}.paskiadb" db_path = os.environ.get("PASKIA_DB", f"{rp_id}.paskiadb")
db_path = os.environ.get("PASKIA_DB", default_path) store = JsonlStore(_ops._db, db_path)
await _ops._store.load(db_path, rp_id=rp_id) await store.load(db_path, rp_id=rp_id)
_ops._db = _ops._store.db _ops._db = store.db
_ops._initialized = True _ops._db._store = store
# Request a snapshot after successful startup
store._snapshot.request_force()
def cleanup_expired() -> int: def cleanup_expired() -> int:
+10 -10
View File
@@ -35,9 +35,9 @@ _UNSAFE_CHARS = re.compile(
# ANSI color codes (matching FastAPI logging style) # ANSI color codes (matching FastAPI logging style)
_RESET = "\033[0m" _RESET = "\033[0m"
_DIM = "\033[2m" _SEP = "\033[38;5;242m" # Dark grey for separators (like host/timing in access log)
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log) _PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix (like host in access log)
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log) _PATH_FINAL = "\033[38;5;250m" # Default for final element (like path in access log)
_DELETE = "\033[1;31m" # Red for deletions _DELETE = "\033[1;31m" # Red for deletions
_ADD = "\033[0;32m" # Green for additions _ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name _ACTION = "\033[1;34m" # Bold blue for action name
@@ -317,7 +317,7 @@ def _format_change_lines(
# Helper to format a value, checking for censored paths # Helper to format a value, checking for censored paths
def fmt_value(v: Any, child_path: list[str]) -> str: def fmt_value(v: Any, child_path: list[str]) -> str:
if child_path[-2:] == ["oidc", "key"]: if child_path[-2:] == ["oidc", "key"]:
return f"{_DIM}<hidden>{_RESET}" return f"{_SEP}<hidden>{_RESET}"
return _format_value(v, resolver=resolver) return _format_value(v, resolver=resolver)
# Helper to format path with UUID replacement # Helper to format path with UUID replacement
@@ -342,12 +342,12 @@ def _format_change_lines(
lines = [] lines = []
# First line: path with green final element and grey = # First line: path with green final element and grey =
if len(formatted_path) == 1: if len(formatted_path) == 1:
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET}") lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}")
else: else:
prefix = ".".join(formatted_path[:-1]) prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1] final = formatted_path[-1]
lines.append( lines.append(
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}" f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}"
) )
# Child lines: indented key: value, with aligned values # Child lines: indented key: value, with aligned values
# Format keys (may contain UUIDs) # Format keys (may contain UUIDs)
@@ -360,24 +360,24 @@ def _format_change_lines(
field_width = max(max_key_len, 12) # minimum 12 chars field_width = max(max_key_len, 12) # minimum 12 chars
for k_display, v_str in formatted_items: for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display)) padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_DIM}:{_RESET}{padding} {v_str}") lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines return lines
else: else:
value_str = fmt_value(value, path) value_str = fmt_value(value, path)
if len(formatted_path) == 1: if len(formatted_path) == 1:
return [ return [
f" {_ADD}{formatted_path[0]}{_RESET} {_DIM}={_RESET} {value_str}" f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
] ]
prefix = ".".join(formatted_path[:-1]) prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1] final = formatted_path[-1]
return [ return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}" f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
] ]
# update: Existing item being updated - normal path colors # update: Existing item being updated - normal path colors
value_str = fmt_value(value, path) value_str = fmt_value(value, path)
path_str = _format_path(path, resolver=resolver) path_str = _format_path(path, resolver=resolver)
return [f" {path_str} {_DIM}={_RESET} {value_str}"] return [f" {path_str} {_SEP}={_RESET} {value_str}"]
def format_diff( def format_diff(
-6
View File
@@ -15,9 +15,6 @@ import uuid7
from paskia import oidc_notify from paskia import oidc_notify
from paskia.config import SESSION_LIFETIME from paskia.config import SESSION_LIFETIME
from paskia.db.jsonl import (
JsonlStore,
)
from paskia.db.structs import ( from paskia.db.structs import (
DB, DB,
Client, Client,
@@ -41,9 +38,6 @@ _UNSET = object()
# Global database instance (empty until init() loads data) # Global database instance (empty until init() loads data)
_db = DB(config=Config(rp_id="uninitialized.invalid")) _db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db)
_db._store = _store
_initialized = False
def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool: def is_username_taken(username: str, exclude_uuid: UUID | None = None) -> bool:
+88
View File
@@ -0,0 +1,88 @@
"""
Snapshot handling for JSONL database persistence.
"""
import logging
from datetime import UTC, datetime
from typing import Any
import msgspec
_logger = logging.getLogger(__name__)
LINEPREFIX = b"SNAPSHOT "
MINDIFFS = 100
class Snapshot(msgspec.Struct):
"""Snapshot data structure for database persistence."""
ts: datetime
v: int
state: dict[str, Any]
class SnapshotState:
"""Tracks snapshot timing and line counts for a database file."""
def __init__(self) -> None:
self.ts: datetime | None = None
self.changes: int = 0
self._force_pending: bool = False
def request_force(self) -> None:
"""Request a forced snapshot on the next maybe_write call."""
self._force_pending = True
def record_lines(self, count: int) -> None:
self.changes += count
def maybe_write(self, file, version: int, state: dict) -> None:
"""Write a snapshot if conditions are met (enough changes, and Sunday UTC or forced)."""
if self.changes < MINDIFFS:
return
force = self._force_pending
now = datetime.now(UTC)
if not force and now.weekday() != 6: # 6 = Sunday
return
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
if not force and self.ts is not None and self.ts >= sunday_midnight:
return
if not file.is_open:
return
try:
self._write(file, version, state, now)
self._force_pending = False
except Exception as exc:
_logger.error("snapshot: failed to write snapshot: %r", exc)
def _write(self, file, version: int, state: dict, now: datetime) -> None:
"""Write a snapshot and update internal state."""
data = msgspec.json.encode(Snapshot(ts=now, v=version, state=state))
file.write(LINEPREFIX + data + b"\n")
self.changes = 0
self.ts = now
@staticmethod
def load(data: bytes) -> tuple[Snapshot | None, int]:
"""Find and parse the last snapshot in file data.
Returns (snapshot, replay_offset) where replay_offset is the byte
position to start replaying change records from. If no valid snapshot
is found, returns (None, 0).
"""
marker = b"\n" + LINEPREFIX
pos = data.rfind(marker)
if pos != -1:
pos += 1 # skip the newline
elif data.startswith(LINEPREFIX):
pos = 0
else:
return None, 0
end = data.find(b"\n", pos)
if end == -1:
raise ValueError("Incomplete snapshot line at end of file")
snap = msgspec.json.decode(data[pos + len(LINEPREFIX) : end], type=Snapshot)
return snap, end + 1
+4 -7
View File
@@ -9,7 +9,6 @@ import msgspec
import uuid7 import uuid7
from paskia import db from paskia import db
from paskia.util import hostutil
from paskia.util import passphrase as passphrase_util from paskia.util import passphrase as passphrase_util
from paskia.util.crypto import hash_secret from paskia.util.crypto import hash_secret
@@ -601,14 +600,14 @@ class OIDC(msgspec.Struct, dict=True):
key: bytes | None = None key: bytes | None = None
class Config(msgspec.Struct, frozen=True, dict=True, omit_defaults=True): class Config(msgspec.Struct, omit_defaults=True):
"""Stored configuration for the instance.""" """Stored configuration for the instance."""
rp_id: str rp_id: str
rp_name: str | None = None rp_name: str | None = None
origins: list[str] | None = None
auth_host: str | None = None auth_host: str | None = None
listen: str | None = None origins: list[str] | None = None
listen: list[str] | None = None
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -679,10 +678,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
if s.client_uuid is not None: if s.client_uuid is not None:
return None return None
# Normalize host for comparison (stored hosts are already normalized)
normalized_input = hostutil.normalize_host(host)
# Validate host matches (sessions are always created with a host) # Validate host matches (sessions are always created with a host)
normalized_input = host
if s.host != normalized_input: if s.host != normalized_input:
# Session bound to different host # Session bound to different host
return None return None
+4 -4
View File
@@ -15,7 +15,7 @@ from fastapi.security import HTTPBearer
from paskia import authcode, db from paskia import authcode, db
from paskia._version import __version__ from paskia._version import __version__
from paskia.authsession import EXPIRES, get_reset from paskia.authsession import EXPIRES, get_reset, session_ctx
from paskia.fastapi import authz, session, user from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
@@ -202,7 +202,7 @@ async def api_user_info(
detail="Authentication required", detail="Authentication required",
mode="login", mode="login",
) )
ctx = db.data().session_ctx(auth, request.headers.get("host")) ctx = session_ctx(auth, request.headers.get("host"))
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, status_code=401,
@@ -249,7 +249,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not auth: if not auth:
return {"message": "Already logged out"} return {"message": "Already logged out"}
host = request.headers.get("host") host = request.headers.get("host")
ctx = db.data().session_ctx(auth, host) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
return {"message": "Already logged out"} return {"message": "Already logged out"}
with suppress(Exception): with suppress(Exception):
@@ -282,7 +282,7 @@ async def api_set_session(
secret = a.session_key secret = a.session_key
# Verify the session exists # Verify the session exists
ctx = db.data().session_ctx(secret, host) ctx = session_ctx(secret, host)
if not ctx: if not ctx:
raise HTTPException(401, f"Session not found on {host}") raise HTTPException(401, f"Session not found on {host}")
+1 -26
View File
@@ -115,25 +115,16 @@ def format_access_log(
client: str, status: int, method: str, host: str, path: str, duration_ms: float client: str, status: int, method: str, host: str, path: str, duration_ms: float
) -> str: ) -> str:
"""Format access log line with colors and aligned fields.""" """Format access log line with colors and aligned fields."""
use_color = sys.stderr.isatty()
# Format components with fixed widths for alignment # Format components with fixed widths for alignment
ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars ip = format_client_ip(client).ljust(19) # IPv6 network max 19 chars
timing = f"{duration_ms:.0f}ms" timing = f"{duration_ms:.0f}ms"
method_padded = method.ljust(7) # Longest method is OPTIONS (7) method_padded = method.ljust(7) # Longest method is OPTIONS (7)
if use_color:
status_str = f"{status_color(status)}{status}{_RESET}" status_str = f"{status_color(status)}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}"
method_str = f"{method_color(method)}{method_padded}{_RESET}" method_str = f"{method_color(method)}{method_padded}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
else:
status_str = str(status)
timing_str = timing
method_str = method_padded
host_str = host
path_str = path
# Format: "IP STATUS METHOD host path TIMING" # Format: "IP STATUS METHOD host path TIMING"
return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}" return f"{ip} {status_str} {method_str} {host_str}{path_str} {timing_str}"
@@ -153,7 +144,6 @@ def _next_ws_id() -> int:
def log_ws_open(ws) -> int: def log_ws_open(ws) -> int:
"""Log WebSocket connection open. Returns connection ID for use in close.""" """Log WebSocket connection open. Returns connection ID for use in close."""
use_color = sys.stderr.isatty()
ws_id = _next_ws_id() ws_id = _next_ws_id()
client = ws.client.host if ws.client else "-" client = ws.client.host if ws.client else "-"
@@ -169,19 +159,11 @@ def log_ws_open(ws) -> int:
origin_host = origin.split("://", 1)[-1] if origin else None origin_host = origin.split("://", 1)[-1] if origin else None
show_origin = origin_host and origin_host != host show_origin = origin_host and origin_host != host
if use_color:
# 🔌 aligned with status (takes ~2 char width), ID aligned with method # 🔌 aligned with status (takes ~2 char width), ID aligned with method
prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}" prefix = f"🔌 {_WS_OPEN}{id_str}{_RESET}"
host_str = f"{_HOST}{host}{_RESET}" host_str = f"{_HOST}{host}{_RESET}"
path_str = f"{_PATH}{path}{_RESET}" path_str = f"{_PATH}{path}{_RESET}"
origin_str = ( origin_str = f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
f" {_RESET}from {_HOST}{origin_host}{_RESET}" if show_origin else ""
)
else:
prefix = f"WS+ {id_str}"
host_str = host
path_str = path
origin_str = f" from {origin_host}" if show_origin else ""
logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}") logger.info(f"{ip} {prefix} {host_str}{path_str}{origin_str}")
return ws_id return ws_id
@@ -209,8 +191,6 @@ WS_CLOSE_CODES = {
def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None: def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
"""Log WebSocket connection close with duration and status.""" """Log WebSocket connection close with duration and status."""
use_color = sys.stderr.isatty()
id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars) id_str = f"{ws_id:02d}".ljust(7) # Align with method field (7 chars)
timing = f"{duration * 1000:.0f}ms" timing = f"{duration * 1000:.0f}ms"
@@ -220,15 +200,10 @@ def log_ws_close(ws_id: int, close_code: int | None, duration: float) -> None:
else: else:
status = WS_CLOSE_CODES.get(close_code, f"code {close_code}") status = WS_CLOSE_CODES.get(close_code, f"code {close_code}")
if use_color:
# 🔌 aligned with status, ID aligned with method # 🔌 aligned with status, ID aligned with method
prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}" prefix = f"🔌 {_WS_CLOSE}{id_str}{_RESET}"
status_str = f"{_WS_STATUS}{status}{_RESET}" status_str = f"{_WS_STATUS}{status}{_RESET}"
timing_str = f"{_TIMING}{timing}{_RESET}" timing_str = f"{_TIMING}{timing}{_RESET}"
else:
prefix = f"WS- {id_str}"
status_str = status
timing_str = timing
logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}") logger.info(f"{' ' * 19} {prefix} {status_str} {timing_str}")
+9 -12
View File
@@ -1,9 +1,9 @@
import json
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
import msgspec
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
@@ -13,7 +13,6 @@ from paskia.bootstrap import bootstrap_if_needed
from paskia.db import start_background, stop_background from paskia.db import start_background, stop_background
from paskia.db.background import flush from paskia.db.background import flush
from paskia.db.logging import configure_db_logging from paskia.db.logging import configure_db_logging
from paskia.db.structs import Config
from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi import admin, api, auth_host, oid, ws
# Import frontend instance # Import frontend instance
@@ -21,6 +20,7 @@ from paskia.fastapi.front import frontend
from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging
from paskia.fastapi.session import AUTH_COOKIE from paskia.fastapi.session import AUTH_COOKIE
from paskia.util import hostutil, passphrase, vitedev from paskia.util import hostutil, passphrase, vitedev
from paskia.util.runtime import RuntimeConfig
# Configure custom logging # Configure custom logging
configure_access_logging() configure_access_logging()
@@ -40,13 +40,13 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
so that uvicorn reload / multiprocess workers inherit the settings. so that uvicorn reload / multiprocess workers inherit the settings.
All keys are guaranteed to exist; values are already normalized by __main__.py. All keys are guaranteed to exist; values are already normalized by __main__.py.
""" """
config = json.loads(os.environ["PASKIA_CONFIG"]) runtime = msgspec.json.decode(os.environ["PASKIA_CONFIG"], type=RuntimeConfig)
try: try:
await globals.init( await globals.init(
rp_id=config["rp_id"], rp_id=runtime.config.rp_id,
rp_name=config["rp_name"], rp_name=runtime.config.rp_name,
origins=config["origins"], origins=runtime.config.origins,
bootstrap=False, bootstrap=False,
) )
except ValueError as e: except ValueError as e:
@@ -55,12 +55,9 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
raise raise
# Bootstrap and persist config now that the full DB is loaded # Bootstrap and persist config now that the full DB is loaded
cli_config_data = config.get("cli_config") await bootstrap_if_needed(config=runtime.config)
if cli_config_data: if runtime.save:
cli_config = Config(**cli_config_data) await db.update_config(runtime.config)
await bootstrap_if_needed(config=cli_config)
if config.get("save"):
await db.update_config(cli_config)
await flush() await flush()
# Restore uvicorn info logging (suppressed during startup in dev mode) # Restore uvicorn info logging (suppressed during startup in dev mode)
+6 -5
View File
@@ -13,6 +13,7 @@ from paskia import db
from paskia.authsession import ( from paskia.authsession import (
delete_credential, delete_credential,
expires, expires,
session_ctx,
) )
from paskia.fastapi import authz, session from paskia.fastapi import authz, session
from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.response import MsgspecResponse
@@ -45,7 +46,7 @@ async def user_update_display_name(
status_code=401, detail="Authentication Required", mode="login" status_code=401, detail="Authentication Required", mode="login"
) )
host = request.headers.get("host") host = request.headers.get("host")
ctx = db.data().session_ctx(auth, host) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
@@ -74,7 +75,7 @@ async def user_update_info(
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login" status_code=401, detail="Authentication Required", mode="login"
) )
ctx = db.data().session_ctx(auth, request.headers.get("host")) ctx = session_ctx(auth, request.headers.get("host"))
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
@@ -112,7 +113,7 @@ async def user_update_theme(
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login" status_code=401, detail="Authentication Required", mode="login"
) )
ctx = db.data().session_ctx(auth, request.headers.get("host")) ctx = session_ctx(auth, request.headers.get("host"))
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
@@ -129,7 +130,7 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
if not auth: if not auth:
return {"message": "Already logged out"} return {"message": "Already logged out"}
host = request.headers.get("host") host = request.headers.get("host")
ctx = db.data().session_ctx(auth, host) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
@@ -151,7 +152,7 @@ async def api_delete_session(
status_code=401, detail="Authentication Required", mode="login" status_code=401, detail="Authentication Required", mode="login"
) )
host = request.headers.get("host") host = request.headers.get("host")
ctx = db.data().session_ctx(auth, host) ctx = session_ctx(auth, host)
if not ctx: if not ctx:
raise authz.AuthException( raise authz.AuthException(
status_code=401, detail="Session expired", mode="login" status_code=401, detail="Session expired", mode="login"
+2 -2
View File
@@ -7,7 +7,7 @@ from fastapi import FastAPI, WebSocket
from paskia import authcode, db from paskia import authcode, db
from paskia.authcode import CookieCode, OIDCCode from paskia.authcode import CookieCode, OIDCCode
from paskia.authsession import get_reset from paskia.authsession import get_reset, session_ctx
from paskia.db.structs import Session from paskia.db.structs import Session
from paskia.fastapi import authz, remote from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.session import AUTH_COOKIE, infodict
@@ -195,7 +195,7 @@ async def websocket_authenticate(
# If there's an existing session, restrict to that user's credentials (reauth) # If there's an existing session, restrict to that user's credentials (reauth)
session_user_uuid = None session_user_uuid = None
if auth: if auth:
existing_ctx = db.data().session_ctx(auth, host) existing_ctx = session_ctx(auth, host)
if existing_ctx: if existing_ctx:
session_user_uuid = existing_ctx.user.uuid session_user_uuid = existing_ctx.user.uuid
+3 -2
View File
@@ -7,6 +7,7 @@ from uuid import UUID
from fastapi import WebSocket from fastapi import WebSocket
from paskia import db from paskia import db
from paskia.authsession import session_ctx
from paskia.db import Credential, SessionContext from paskia.db import Credential, SessionContext
from paskia.fastapi.session import infodict from paskia.fastapi.session import infodict
from paskia.fastapi.wsutil import validate_origin from paskia.fastapi.wsutil import validate_origin
@@ -90,7 +91,7 @@ async def authenticate_and_login(
# Get credential IDs if restricting to a user's credentials # Get credential IDs if restricting to a user's credentials
credential_ids = None credential_ids = None
if auth: if auth:
existing_ctx = db.data().session_ctx(auth, host) existing_ctx = session_ctx(auth, host)
if existing_ctx: if existing_ctx:
credential_ids = existing_ctx.user.credential_ids or None credential_ids = existing_ctx.user.credential_ids or None
@@ -107,7 +108,7 @@ async def authenticate_and_login(
) )
# Fetch and return the full session context # Fetch and return the full session context
ctx = db.data().session_ctx(secret, normalized_host) ctx = session_ctx(secret, host)
if not ctx: if not ctx:
raise ValueError("Failed to create session context") raise ValueError("Failed to create session context")
return ctx, secret return ctx, secret
+1 -1
View File
@@ -13,7 +13,7 @@ import httpx
from paskia import db from paskia import db
from paskia.util import oidjwt from paskia.util import oidjwt
from paskia.util.hostutil import _load_config from paskia.util.runtime import _load_config
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
+26 -16
View File
@@ -1,27 +1,23 @@
"""Utilities for determining the auth UI host and base URLs.""" """Utilities for determining the auth UI host and base URLs."""
import json
import os
from functools import lru_cache
from urllib.parse import urlparse, urlsplit from urllib.parse import urlparse, urlsplit
from paskia.util.runtime import _load_config
@lru_cache(maxsize=1)
def _load_config() -> dict: def _cfg():
"""Load PASKIA_CONFIG JSON.""" return _load_config()
config_json = os.getenv("PASKIA_CONFIG")
if not config_json:
return {}
return json.loads(config_json)
def is_root_mode() -> bool: def is_root_mode() -> bool:
return _load_config().get("auth_host") is not None cfg = _cfg()
return cfg is not None and cfg.config.auth_host is not None
def dedicated_auth_host() -> str | None: def dedicated_auth_host() -> str | None:
"""Return configured auth_host netloc, or None.""" """Return configured auth_host netloc, or None."""
auth_host = _load_config().get("auth_host") cfg = _cfg()
auth_host = cfg.config.auth_host if cfg else None
if not auth_host: if not auth_host:
return None return None
@@ -35,8 +31,10 @@ def ui_base_path() -> str:
def auth_site_url() -> str: def auth_site_url() -> str:
"""Return the base URL for the auth site UI (computed at startup).""" """Return the base URL for the auth site UI (computed at startup)."""
cfg = _load_config() cfg = _cfg()
return cfg.get("site_url", "https://localhost") + cfg.get("site_path", "/auth/") if cfg:
return cfg.site_url + cfg.site_path
return "https://localhost/auth/"
def reset_link_url(token: str) -> str: def reset_link_url(token: str) -> str:
@@ -45,10 +43,10 @@ def reset_link_url(token: str) -> str:
def normalize_origin(origin: str) -> str: def normalize_origin(origin: str) -> str:
"""Normalize an origin URL by adding https:// if no scheme is present.""" """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes."""
if "://" not in origin: if "://" not in origin:
return f"https://{origin}" return f"https://{origin}"
return origin return origin.rstrip("/")
def reload_config() -> None: def reload_config() -> None:
@@ -74,3 +72,15 @@ def normalize_host(raw_host: str | None) -> str | None:
# Strip port from host:port # Strip port from host:port
netloc = netloc.rsplit(":", 1)[0] netloc = netloc.rsplit(":", 1)[0]
return netloc.lower() or None return netloc.lower() or None
def format_endpoint(ep: dict) -> str:
"""Format an endpoint dict to a listen string (e.g. 'unix:/path' or 'host:port')."""
if uds := ep.get("uds"):
return f"unix:{uds}"
host = ep["host"]
port = ep["port"]
# Bracket IPv6 addresses
if ":" in host:
host = f"[{host}]"
return f"{host}:{port}"
+2 -2
View File
@@ -3,7 +3,7 @@
from collections.abc import Sequence from collections.abc import Sequence
from fnmatch import fnmatchcase from fnmatch import fnmatchcase
from paskia import db from paskia.authsession import session_ctx
from paskia.util.hostutil import normalize_host from paskia.util.hostutil import normalize_host
__all__ = ["has_any", "has_all", "session_context"] __all__ = ["has_any", "has_all", "session_context"]
@@ -40,4 +40,4 @@ async def session_context(auth: str | None, host: str | None = None):
if not auth: if not auth:
return None return None
normalized_host = normalize_host(host) if host else None normalized_host = normalize_host(host) if host else None
return db.data().session_ctx(auth, normalized_host) return session_ctx(auth, normalized_host)
+31
View File
@@ -0,0 +1,31 @@
"""Runtime configuration utilities."""
import os
from functools import lru_cache
import msgspec
from paskia.db.structs import Config
class RuntimeConfig(msgspec.Struct):
"""Runtime configuration for the Paskia authentication server.
Wraps the db Config (CLI/stored settings) with computed runtime fields.
Serialized to PASKIA_CONFIG env var as JSON via msgspec.
"""
config: Config # CLI/stored configuration to persist
site_url: str # Base URL without trailing path (e.g. https://example.com)
site_path: str # Path to auth UI: "/" if auth_host, else "/auth/"
save: bool = False # Whether to persist config to database
@lru_cache(maxsize=1)
def _load_config() -> "RuntimeConfig | None":
"""Load RuntimeConfig from PASKIA_CONFIG env var."""
config_json = os.getenv("PASKIA_CONFIG")
if not config_json:
return None
return msgspec.json.decode(config_json.encode(), type=RuntimeConfig)
+28 -24
View File
@@ -1,14 +1,19 @@
"""Startup configuration box formatting utilities.""" """Startup configuration box formatting utilities."""
from __future__ import annotations
import os import os
import re import re
from sys import stderr from sys import stderr
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__ from paskia._version import __version__
from paskia.util.hostutil import format_endpoint
if TYPE_CHECKING: if TYPE_CHECKING:
from paskia.config import PaskiaConfig from paskia.util.runtime import RuntimeConfig
BOX_WIDTH = 60 # Inner width (excluding box chars) BOX_WIDTH = 60 # Inner width (excluding box chars)
@@ -42,7 +47,7 @@ def bottom() -> str:
return "" + "" * (BOX_WIDTH + 2) + "\n" return "" + "" * (BOX_WIDTH + 2) + "\n"
def print_startup_config(config: "PaskiaConfig") -> None: def print_startup_config(runtime: RuntimeConfig) -> None:
"""Print server configuration on startup.""" """Print server configuration on startup."""
# Key graphic with yellow shading (bright for highlights, dark for body) # Key graphic with yellow shading (bright for highlights, dark for body)
y = YELLOW # Dark yellow for main body y = YELLOW # Dark yellow for main body
@@ -57,41 +62,40 @@ def print_startup_config(config: "PaskiaConfig") -> None:
lines.append( lines.append(
line( line(
f"{b}{y} {b}{y}▀▀▀▀{b}{y}▀▀{b}{y}▀▀{b}{r} {w}" f"{b}{y} {b}{y}▀▀▀▀{b}{y}▀▀{b}{y}▀▀{b}{r} {w}"
+ config.site_url + runtime.site_url
+ config.site_path + runtime.site_path
+ r + r
) )
) )
lines.append(line(f" {y}▀▀▀▀▀{r}")) lines.append(line(f" {y}▀▀▀▀▀{r}"))
# Format auth host section # Format auth host section
if config.auth_host: if runtime.config.auth_host:
lines.append(line(f"Auth Host: {config.auth_host}")) lines.append(line(f"Auth Host: {runtime.config.auth_host}"))
from paskia.__main__ import DEFAULT_PORT as P # noqa: PLC0415 - circular
from paskia.__main__ import DEVMODE # noqa: PLC0415 - circular
# Show frontend URL if in dev mode # Show frontend URL if in dev mode
devmode = os.environ.get("PASKIA_VITE_URL") if DEVMODE:
if devmode: lines.append(line(f"Dev Frontend: {os.environ.get('PASKIA_VITE_URL')}"))
lines.append(line(f"Dev Frontend: {devmode}"))
# Format listen address with scheme # Format listen endpoints (dev mode only uses the first endpoint)
if config.uds:
listen = f"unix:{config.uds}" endpoints = list(parse_endpoints(runtime.config.listen, P))
elif config.host: if DEVMODE:
listen = f"http://{config.host}:{config.port}" endpoints = endpoints[:1] # server.run reload=True uses only one
else: parts = [format_endpoint(ep) for ep in endpoints]
listen = f"http://0.0.0.0:{config.port} + [::]:{config.port}" lines.append(line(f"Backend: {' '.join(parts)}"))
lines.append(line(f"Backend: {listen}"))
# Relying Party line (omit name if same as id) # Relying Party line (omit name if same as id)
rp_id = config.rp_id rp_id = runtime.config.rp_id
rp_name = config.rp_name rp_name = runtime.config.rp_name
if rp_name and rp_name != rp_id: suffix = f" ({rp_name})" if rp_name and rp_name != rp_id else ""
lines.append(line(f"Relying Party: {rp_id} ({rp_name})")) lines.append(line(f"Relying Party: {rp_id}{suffix}"))
else:
lines.append(line(f"Relying Party: {rp_id}"))
# Format origins section # Format origins section
allowed = config.origins allowed = runtime.config.origins
if allowed: if allowed:
lines.append(line("Permitted Origins:")) lines.append(line("Permitted Origins:"))
for origin in sorted(allowed): for origin in sorted(allowed):