mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
142 lines
5.4 KiB
Python
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))
|