cli.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """Bambuddy administrative CLI.
  2. Invoked via ``python -m backend.app.cli <subcommand>``.
  3. Currently provides ``kiosk-bootstrap`` for creating the SpoolBuddy kiosk
  4. API key during install (see ``spoolbuddy/install/install.sh``).
  5. """
  6. from __future__ import annotations
  7. import argparse
  8. import asyncio
  9. import sys
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import async_sessionmaker
  12. from backend.app.core.auth import generate_api_key
  13. from backend.app.core.database import async_session as default_session_maker, init_db
  14. from backend.app.core.db_dialect import upsert_setting
  15. from backend.app.models.api_key import APIKey
  16. from backend.app.models.settings import Settings
  17. DEFAULT_KIOSK_KEY_NAME = "spoolbuddy-kiosk"
  18. class KioskBootstrapError(RuntimeError):
  19. """Raised when an existing kiosk key would be silently overwritten."""
  20. async def kiosk_bootstrap(
  21. name: str,
  22. *,
  23. force: bool,
  24. session_maker: async_sessionmaker | None = None,
  25. ensure_schema: bool = True,
  26. ) -> str:
  27. """Create (or rotate) an API key for the SpoolBuddy kiosk and return it.
  28. The returned value is the one-time full key string; callers are responsible
  29. for writing it somewhere secure — it cannot be retrieved again.
  30. """
  31. if ensure_schema and session_maker is None:
  32. await init_db()
  33. maker = session_maker or default_session_maker
  34. async with maker() as db:
  35. existing = (await db.execute(select(APIKey).where(APIKey.name == name))).scalar_one_or_none()
  36. if existing and not force:
  37. raise KioskBootstrapError(
  38. f"API key {name!r} already exists (prefix={existing.key_prefix}). Re-run with --force to rotate."
  39. )
  40. if existing:
  41. await db.delete(existing)
  42. await db.flush()
  43. full_key, key_hash, key_prefix = generate_api_key()
  44. row = APIKey(
  45. name=name,
  46. key_hash=key_hash,
  47. key_prefix=key_prefix,
  48. can_queue=False,
  49. can_control_printer=False,
  50. can_read_status=True,
  51. can_manage_library=False,
  52. # SpoolBuddy kiosk writes NFC scans / scale readings / system
  53. # commands via the /spoolbuddy/* routes — all gated by
  54. # can_manage_inventory now, so the bundled key must opt in.
  55. can_manage_inventory=True,
  56. # Kiosk doesn't need maintenance writes; keep it False so the
  57. # bundled key stays minimally scoped (#1832 follow-up).
  58. can_manage_maintenance=False,
  59. # Kiosk doesn't manage print archives either — keep it minimally
  60. # scoped (#1888).
  61. can_manage_archives=False,
  62. printer_ids=None,
  63. enabled=True,
  64. expires_at=None,
  65. )
  66. db.add(row)
  67. # Mark first-run setup as completed so the kiosk URL loads directly
  68. # instead of being force-redirected to /setup by AuthContext. Without
  69. # this, a bundled SpoolBuddy/Bambuddy install boots into the Bambuddy
  70. # first-run wizard (touch-only Pi has no keyboard to complete it).
  71. # Users who want authentication enable it later from the admin UI; the
  72. # API key we just created is already valid so the kiosk keeps working.
  73. await upsert_setting(db, Settings, "setup_completed", "true")
  74. await db.commit()
  75. return full_key
  76. def main(argv: list[str] | None = None) -> int:
  77. parser = argparse.ArgumentParser(
  78. prog="python -m backend.app.cli",
  79. description="Bambuddy administrative commands",
  80. )
  81. sub = parser.add_subparsers(dest="command", required=True)
  82. kiosk = sub.add_parser(
  83. "kiosk-bootstrap",
  84. help="Create an API key for the SpoolBuddy kiosk",
  85. description=(
  86. "Create (or rotate with --force) an API key scoped for the SpoolBuddy "
  87. "kiosk. The full key is printed to stdout — capture it into "
  88. "spoolbuddy/.env as SPOOLBUDDY_API_KEY."
  89. ),
  90. )
  91. kiosk.add_argument(
  92. "--name",
  93. default=DEFAULT_KIOSK_KEY_NAME,
  94. help=f"Key name in the DB (default: {DEFAULT_KIOSK_KEY_NAME})",
  95. )
  96. kiosk.add_argument(
  97. "--force",
  98. action="store_true",
  99. help="Rotate an existing key with the same name (deletes the old one)",
  100. )
  101. args = parser.parse_args(argv)
  102. if args.command == "kiosk-bootstrap":
  103. try:
  104. key = asyncio.run(kiosk_bootstrap(args.name, force=args.force))
  105. except KioskBootstrapError as exc:
  106. print(str(exc), file=sys.stderr)
  107. return 1
  108. print(key)
  109. return 0
  110. return 2
  111. if __name__ == "__main__":
  112. raise SystemExit(main())