steam-backlog-enforcer/steam_backlog_enforcer/_web_privileged.py
2026-10-10 16:07:42 +02:00

142 lines
5.4 KiB
Python

"""Privileged commands: ``POST /api/jobs`` requests the root daemon carries out.
The web server never gets root, so ``unblock``, ``buy-dlc``, ``block-gaming``,
``gaming-unblock``, ``gaming-reset`` and the real ``enforce`` restart become
control-socket ops. The typed phrase is passed through untouched: the daemon
is the trust boundary and checks it, along with the locks and caps.
A completed op is recorded as a finished job (:mod:`.jobs._daemon_jobs`) so
it shows in the same history as everything else; so is an op the daemon
ran and failed (``op_failed``). Refusals — wrong phrase, locked, rate limited,
daemon unreachable — are answered as errors and leave no job behind.
``gaming-reset`` answers ``202`` with a ``PendingAction`` instead: its job is
written when it is committed (:func:`._web_daemon.commit_view`).
"""
from __future__ import annotations
from datetime import datetime
from http import HTTPStatus
from typing import TYPE_CHECKING, Final
from steam_backlog_enforcer import _ctl_client
from steam_backlog_enforcer._store_window import DEFAULT_WINDOW_MINUTES
from steam_backlog_enforcer._web_daemon import daemon_call, pending_action
from steam_backlog_enforcer._web_errors import ApiError
from steam_backlog_enforcer._web_io import Reply
from steam_backlog_enforcer.jobs._daemon_jobs import (
EXIT_FAILED,
EXIT_OK,
DaemonOutcome,
record_daemon_job,
)
from steam_backlog_enforcer.jobs._events import now_iso
from steam_backlog_enforcer.jobs._view import read_job
if TYPE_CHECKING:
from collections.abc import Callable, Mapping
from steam_backlog_enforcer._command_params import ParamValue
type Outcome = tuple[str, dict[str, object]]
type Op = Callable[[Mapping[str, ParamValue], str], Outcome]
# buy-dlc has no knobs: always the default-length store window.
BUY_DLC_MINUTES: Final = DEFAULT_WINDOW_MINUTES
def _local_hhmm(iso: str | None) -> str:
"""``HH:MM`` local time of an ISO timestamp (the raw text if unparsable)."""
if not iso:
return "?"
try:
return datetime.fromisoformat(iso).astimezone().strftime("%H:%M")
except ValueError:
return iso
def _store_window(minutes: int, phrase: str) -> Outcome:
"""Open the store for *minutes*."""
window = daemon_call(_ctl_client.store_unblock, minutes, phrase)
summary = (
f"Store unblocked for {window.minutes} min, until {_local_hhmm(window.until)}."
)
return summary, {"minutes": window.minutes, "until": window.until}
def _unblock(params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``unblock`` — a 1-30 minute store window."""
return _store_window(int(params["minutes"]), phrase)
def _buy_dlc(_params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``buy-dlc`` — the fixed default-length store window."""
return _store_window(BUY_DLC_MINUTES, phrase)
def _block_gaming(params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``block-gaming`` — the irreversible total block."""
result = daemon_call(_ctl_client.block_gaming, int(params["days"]), phrase)
summary = f"Total gaming block active for {result.days} day(s)."
return summary, {"days": result.days, "until": result.until}
def _gaming_unblock(_params: Mapping[str, ParamValue], phrase: str) -> Outcome:
"""``gaming-unblock`` — force-release the playtime mounts."""
released = daemon_call(_ctl_client.gaming_unblock, phrase)
return f"Released {len(released)} playtime mount(s).", {"released": released}
def _restart(_params: Mapping[str, ParamValue], _phrase: str) -> Outcome:
"""``enforce`` (restart) — flush, exit, come back under systemd."""
available = daemon_call(_ctl_client.restart)
summary = "The enforcer daemon is restarting (state flushed first)."
return summary, {"restart_available_at": available}
OPS: Final[dict[str, Op]] = {
"unblock": _unblock,
"buy-dlc": _buy_dlc,
"block-gaming": _block_gaming,
"gaming-unblock": _gaming_unblock,
"enforce": _restart,
}
def run_privileged(
command: str, params: Mapping[str, ParamValue], phrase: str | None
) -> Reply:
"""Carry out a privileged command through the daemon.
Args:
command: Canonical command name.
params: Params already validated against the command's spec.
phrase: ``JobRequest.confirm_phrase`` (checked by the daemon).
Returns:
``201`` with the recorded ``Job``, or ``202`` with a
``PendingAction`` for ``gaming-reset``.
Raises:
ApiError: The daemon's refusal, mapped to its HTTP status.
"""
typed = phrase or ""
if command == "gaming-reset":
pending = daemon_call(_ctl_client.gaming_reset_arm, typed)
return Reply(HTTPStatus.ACCEPTED, pending_action(pending))
op = OPS.get(command)
if op is None:
msg = f"{command!r} is not a daemon command."
raise ApiError(msg, code="unknown_command")
created_at = now_iso()
try:
summary, data = op(params, typed)
except ApiError as exc:
if exc.code == "op_failed":
failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED)
record_daemon_job(command, params, created_at=created_at, outcome=failed)
raise
done = DaemonOutcome(ok=True, summary=summary, exit_code=EXIT_OK, data=data)
job_id = record_daemon_job(command, params, created_at=created_at, outcome=done)
return Reply(HTTPStatus.CREATED, read_job(job_id))